feat(keyring-controller): add withController for atomic operations over multiple keyrings#8416
feat(keyring-controller): add withController for atomic operations over multiple keyrings#8416
withController for atomic operations over multiple keyrings#8416Conversation
e1b0a6b to
001d092
Compare
withController for atomic operations over multiple keyrings
b74e2c3 to
02a9fac
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 02a9fac. Configure here.
21276f6 to
0f03c1f
Compare
…er multiple keyrings
0f03c1f to
7fa0b28
Compare
| try { | ||
| result = await operation(restrictedController); | ||
| } catch (error) { | ||
| await destroyKeyrings(createdEntries); |
There was a problem hiding this comment.
Hmm I think we're overlooking the side-effects of if addNewKeyring is used in a failed operation. If data is passed into deserialize the snap would have created phantom accounts.
| // still have references to them. | ||
| ...removedEntries, | ||
| ]) { | ||
| this.#assertNoUnsafeDirectKeyringAccess(result, keyring); |
There was a problem hiding this comment.
This check can be bypassed if the consumer does something like this no? 🤔:
let leaked: RestrictedController;
await keyringController.withController(async (rc) => {
leaked = rc; // return value is void, so the direct-access check never fires
return undefined;
});
// lock released here
// Now, totally outside any lock:
const entry = leaked.keyrings[0]; // getter still works
const keyring = entry.keyring; // raw live keyring instance
await keyring.addAccounts(5); // mutation without controller lock
// Concurrent `withKeyring` or `signTransaction` on another path
// could be reading this exact instance| }, | ||
|
|
||
| // Method to remove a keyring from the restricted entries. | ||
| removeKeyring: async (id: string) => { |
There was a problem hiding this comment.
WDYT about adding a guard to make sure we're not removing the last HD keyring here similar to removeAccount? Otherwise a consumer can stage the removal, the callback resolves, destroyKeyrings(removedEntries) tears down the original HD keyring instance, then the vault commit (#updateVault) fails and #withRollback recovers state via #restoreSerializedKeyrings, which destroys everything in this.#keyrings and rebuilds new instances from the snapshot. You then have:
- The error surfacing at commit time as
NoHdKeyring, not at theremoveKeyring()line that caused it which would be harder to debug. - Any external code holding a reference to the keyring instance is now pointing at a destroyed instance.

Explanation
Today there's no way to make multiple operations in an "atomic" (read transactional) way.
A good example of this is if you want to use a keyring using
withKeyringthat's not existing yet (I'm omitting thecreateIfMissingvariants, as we wanted to move away from this pattern).To do this in a safe way, you usually have to use your own lock to make sure you can get-or-create the keyring and prevent concurrent keyring creations.
This new
withControlleris based on thewithKeyringbut with an access to a "restricted" state and methods of the controller. This way, you can interact with multiple keyring at once while being guarded (to prevent race-conditions) by the controller's global lock.The former problem can then be written that way now:
This will also be used to write the migration from the existing
SnapKeyring(1 for ALL Snaps) to multipleSnapKeyring(1 PER Snap) in a safe way like:References
N/A
Checklist
Note
Medium Risk
Touches keyring lifecycle and transactional persistence logic (add/remove/destroy + rollback), which could impact vault/keyring state consistency if edge cases are missed. Changes are well-covered by new unit tests but still affect a critical controller surface.
Overview
Adds
KeyringController.withController, exposing a restricted transactional view of all keyrings that allows stagingaddNewKeyring/removeKeyringchanges and committing them atomically (or rolling back and cleaning up created keyrings on error).Wires
withControllerthrough the messenger (KeyringController:withController), exports the new action type, updates shared entry typing (KeyringEntry) used bywithKeyring, and expands tests/mocks to validate locking, immediate staged visibility, rollback/destroy semantics, vault persistence behavior, and prevention of returning raw keyring references.Reviewed by Cursor Bugbot for commit 7fa0b28. Bugbot is set up for automated code reviews on this repo. Configure here.